boto3から無理やりSecurity Hubのセキュリティスコアを算出してみた
こんにちは、臼田です。
みなさん、Security Hubでセキュリティチェックしてますか?(挨拶
今回はタイトルの通りboto3を使って無理やりSecurity Hubのセキュリティスコアを算出してみました。
背景
AWS Security HubはAWS環境をチェックし、セキュリティの状況とスコアを出してくれる素晴らしいサービスです。以下のようにマネジメントコンソールでわかりやすく確認できます。
しかしこのセキュリティスコア表示、実はユーザー側で実行できるAPIでは取得できません。
というわけで、このスコアを頑張って自分で算出してみました。
動きを分析して戦略を考える
まずはセキュリティスコアがどのような根拠に基づいてスコアを算出しているか確認しましょう。
Security Hubのセキュリティ基準の詳細画面上部の概要には2つのグラフがあります。1つはセキュリティスコアの半円グラフで、下にコントロールと書いてあります。もう1つは横棒グラフでチェックと書いてあります。
コントロールは、IAM.6
やEC2.19
などのセキュリティ基準の確認項目にあたり、コントロール毎にコンプライアンス違反があるかステータスを持っています。コントロールには実際にリソースをチェックした結果のFindingsが紐付けられ、Findings単位でもコンプライアンス違反のステータスを持っています。
セキュリティスコアのグラフはコントロールの数に対する違反の無いコントロールの割合で、チェックのグラフはFindingsの数に対する、違反のあるFindingsの割合です。
セキュリティスコアはコントロールのコンプライアンス状態が基準となっていて、個別のFindingsの影響を直接受けないことが理解できます。
というわけで各コントロールのコンプライアンス状態を確認したくなるわけですが、実はこれも直接取得するAPIはユーザー側に公開されていません!なので各コントロールのコンプライアンスについても頑張って導き出します。
各コントロールのコンプライアンスは主に3種類の状態があります。チェックした結果が存在しないNO_DATA
、すべてのFindingsが要件を満たしているPASSED
、1つでも違反したFindingsが存在しているFAILED
。
各コントロール毎にFindingsを収集し、それらを分析して3種類のどれに当たるか判断すれば良さそうです。
そしてセキュリティスコアの算出方法ですが、先程の画像では135 / 149
の計算により91%
が導出されていました。135
は成功(PASSED
)しているコントロールの数です。149
は成功と失敗(FAILED
)の14
を足した数です。つまりスコアの計算には成功と失敗の数だけが関与しているようです。(不明は今回は無視)
135 / 149
の結果はだいたい0.9060
となるため、切り上げか四捨五入されているようです。今回は切り上げで行ってみます。
以上でだいたい戦略が決まりました。まとめると以下になります。
- 該当セキュリティ基準に関連するすべてのFindingsを取得する
- すべてのFindingsのコンプライアンスから各コントロールのコンプライアンスを導出する
- コントロールの成功数と失敗数を計算してセキュリティスコアを算出する
これでやっていきましょう。
実装
スクリプト
出来上がったスクリプトがこちらになります。一応Lambdaでもシェルからでも実行できるようにしてあります。
import sys import math import logging import collections import boto3 logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(event, context): logger.info( "[START] get_control_finding_summary") # params standard_name = "aws-foundational-security-best-practices/v/1.0.0" account_id = boto3.client('sts').get_caller_identity()['Account'] securityhub_us_east_1 = boto3.client( 'securityhub', region_name="us-east-1") # get controls std_subsc_arn = "arn:aws:securityhub:{}:{}:subscription/{}".format( "us-east-1", account_id, standard_name) describe_standards_controls_paginator = securityhub_us_east_1.get_paginator( 'describe_standards_controls') describe_standards_controls_iterator = describe_standards_controls_paginator.paginate( StandardsSubscriptionArn=std_subsc_arn) controls = [] for r in describe_standards_controls_iterator: controls.extend(r['Controls']) logger.info("controls: " + str(len(controls))) # create get_findings() filters securityhub = boto3.client('securityhub') target_findings = [] filters = { "RecordState": [ { "Value": "ACTIVE", "Comparison": "EQUALS" } ], "UpdatedAt": [ { "DateRange": { "Value": 1, "Unit": "DAYS" } } ], "ProductName": [ { "Value": "Security Hub", "Comparison": "EQUALS" } ], "GeneratorId": [ { "Value": "aws-foundational-security-best-practices/v/1.0.0/", "Comparison": "PREFIX" } ], "WorkflowStatus": [ { "Value": "SUPPRESSED", "Comparison": "NOT_EQUALS" } ] } # create target findings list get_findings_paginator = securityhub.get_paginator('get_findings') get_findings_iterator = get_findings_paginator.paginate( Filters=filters, SortCriteria=[ { "Field": "GeneratorId", "SortOrder": "asc" } ], MaxResults=100 ) for r in get_findings_iterator: target_findings.extend(r['Findings']) logger.debug("now target count: " + str(len(target_findings))) logger.info("target findings: " + str(len(target_findings))) # check control compliance status control_statuses = {x['ControlId']: "NO_DATA" for x in controls} for f in target_findings: f_control_id = f['ProductFields']['ControlId'] if control_statuses.get(f_control_id) is None: continue elif control_statuses[f_control_id] == "FAILED": continue elif control_statuses[f_control_id] == "NO_DATA" and f['Compliance']['Status'] == "PASSED": control_statuses[f_control_id] = "PASSED" elif (control_statuses[f_control_id] == "PASSED" or control_statuses[f_control_id] == "NO_DATA") and f['Compliance']['Status'] == "FAILED": control_statuses[f_control_id] = "FAILED" # count statuses statuses_count = collections.Counter(control_statuses.values()) logger.info(str(statuses_count)) # get SecurityScore passed = statuses_count['PASSED'] failed = statuses_count['FAILED'] no_data = statuses_count['NO_DATA'] security_score = math.ceil(passed / (passed + failed) * 100) res = { "SecurityScore": security_score, "ControlSummary": { "PassedCount": passed, "FailedCount": failed, "NoDataCount": no_data } } logger.info(str(res)) logger.info( "[END] get_control_finding_summary") return res # call lambda_handler if __name__ == "__main__": logger.addHandler(logging.StreamHandler(stream=sys.stdout)) r = lambda_handler({}, {})
とりあえず動くところまでの実装なのでご容赦を。実行すると下記結果が得られます。
$ time python3 get_control_finding_summary.py [START] get_control_finding_summary controls: 188 target findings: 1123 Counter({'PASSED': 135, 'NO_DATA': 39, 'FAILED': 14}) {'SecurityScore': 91, 'ControlSummary': {'PassedCount': 135, 'FailedCount': 14, 'NoDataCount': 39}} [END] get_control_finding_summary real 0m9.628s user 0m0.486s sys 0m0.050s
上記はCloudShellからの実行で、1つのAWSアカウントで全リージョン集約している状況で、Findingsが1123個、所要時間は約10秒でした。
少し実装のポイントなどを解説します。
コントロール一覧の取得
ユーザー側に提供されているコントロール一覧の取得APIはdescribe_standards_controls
のみです。このAPIの注意点は実行しているリージョンによって結果が変わるということです。
Security Hubのコントロールは対象がリージョナルリソースである場合とグローバルリソースである場合があります。グローバルリソースが対象のコントロールはus-east-1(バージニア北部)にしか作成されません(IAMを除く)。一方で、今回のように全リージョン集約をしていて、集約先(このスクリプトが実行される場所)がus-east-1でない場合には、集約先リージョンにはコントロールが無いけど、そのFindingsはus-east-1から送られてくるという状況になります。
というわけで、完全なコントロール一覧を取得するために、今回はus-east-1でdescribe_standards_controls
を実行してきました。なお、これを実行するためにはus-east-1でSecurity Hubが有効化され、対象のスタンダードが有効化される必要があります。これを行わなくても頑張ればコントロール一覧を取得する方法がありそうですが、この実装のほうが楽ですし、なにより集約していてus-east-1を有効化しないパターンが少ないと思ったので良しとしました。
paginatorの活用
Security HubのAPIは結構大量のデータを取得したりすることが多いです。1度に取得できないことも多いので、NextTokenを利用してループする必要があります。そんな時に便利なのがpaginatorです。これはSecurity Hub以外でもそうですが、boto3で簡単に大量のデータを扱う時に役立ちます。
ちなみにスロットリングに気をつける必要があります。APIガイドなどには下記の制限が書かれています。
BatchEnableStandards
-RateLimit
of 1 request per second,BurstLimit
of 1 request per second.GetFindings
-RateLimit
of 3 requests per second.BurstLimit
of 6 requests per second.BatchImportFindings
-RateLimit
of 10 requests per second.BurstLimit
of 30 requests per second.BatchUpdateFindings
-RateLimit
of 10 requests per second.BurstLimit
of 30 requests per second.UpdateStandardsControl
-RateLimit
of 1 request per second,BurstLimit
of 5 requests per second.- All other operations -
RateLimit
of 10 requests per second.BurstLimit
of 30 requests per second.
スロットリングについてもboto3ではリトライする仕組みがついているので、ある程度は任せておけます。
Filtersの作成方法
Findingsを取得するget_findingsを実行する際にFilters
を指定する必要があります。どのような値が適切かを検討する時に、Security Hubのコントロール詳細画面をブラウザのDeveloper Toolsを使いトラフィックを確認すると、下記を指定していました。
{ "Filters": { "RecordState": [ { "Value": "ACTIVE", "Comparison": "EQUALS" } ], "UpdatedAt": [ { "DateRange": { "Value": 1, "Unit": "DAYS" } } ], "ProductName": [ { "Value": "Security Hub", "Comparison": "EQUALS" } ], "GeneratorId": [ { "Value": "aws-foundational-security-best-practices/v/1.0.0/Lambda.1", "Comparison": "EQUALS" } ], "WorkflowStatus": [ { "Value": "SUPPRESSED", "Comparison": "NOT_EQUALS" } ] }, "SortCriteria": [ { "Field": "ComplianceStatus", "SortOrder": "asc" } ], "MaxResults": 100 }
これを応用することにしました。すべてのコントロールを取得する必要があることからGeneratorId
についてはPREFIXを指定してAWS基礎セキュリティベストプラクティスのすべてを対象としました。
また、確認しやすいようにSortCriteria
パラメータもGeneratorId
にしました。
AWSアカウントを集約した環境での動作確認
このスクリプトを12個のメンバーアカウントを集約した環境でも実行してみました。下記が画面上の値です。
実行した結果が以下です。
$ time python3 get_control_finding_summary.py [START] get_control_finding_summary controls: 188 target findings: 59140 Counter({'PASSED': 139, 'FAILED': 49}) {'SecurityScore': 74, 'ControlSummary': {'PassedCount': 139, 'FailedCount': 49, 'NoDataCount': 0}} [END] get_control_finding_summary real 4m23.546s user 0m11.131s sys 0m0.638s
画面と差があります。スコアはマネジメントコンソールでは82%ですが、スクリプトでは74%です。Findingsの数も違います。この差には2つの理由があります。
1つ目はマネジメントコンソール上の概要は24時間更新であるということです。今回のスクリプトもチェックに約4分半とぼちぼち時間がかかっています。こんな処理を毎回していては大変なので、24時間更新なのは理解できますね。ただし、こちらはそんなに差に影響していません。
2つ目はデータなしの項目が、実際はデータがあるということです。今回は集約先のap-northeast-1(東京)で確認していますが、マネジメントコンソール上ではグローバルリソースのコントロールについてはデータなしの扱いに自動的になっています。しかし実際には集約した情報の中にこれらのコントロールのFindingsが含まれているためデータがあります。スクリプトではこれらを適切に処理して算出しているため、データなしが減り、セキュリティスコアの母数が変わるため大きくスコアに影響しています。
つまりマネジメントコンソールのセキュリティスコアよりも正しいスコアを算出しています!
今回の状況では12個のAWSアカウントを集約した環境で、約4分半とLambdaの最大実行時間圏内で処理することができました。ただ、アカウントが増えたりすることも考えると、StepFunctionsでラッピングしてアカウント単位で実行とか、別の手段も検討したほうがいいかもしれません。
まとめ
Security Hubのセキュリティスコアを頑張って算出してみました。
この値が取れると運用上役立つ場面があると思うのでぜひご活用ください。
API提供はよ!